{# THE chat surface: thread, jump button, staged images, composer. The AI page's Chat section includes it and the drawer fetches it; there is one instance on a page at a time (the drawer empties while the Chat section shows), so the ids the scripts and the composer target stay unique. Needs: assistant, chat_path, stream_url, turn_defaults, messages, conversation_id, picker (the catalog is there to pick from), voice (how a spoken turn is heard, or none). #} {% from "components/macros/icons.html" import icon, sprite %} {% from "components/macros/chat.html" import button_classes, conversation_field, voice_states %} {{ sprite() }}
    {% include "partials/chat/transcript.html" %}
{# Staged images live in the script (they ride the stream request, one turn only); the chips are drawn from that list. #} {# The markup the script clones: a staged image's chip, and the image viewer's body for the modal. Styling lives here, not in JS. #} {% if voice %} {# What the microphone is doing, read out as well as shown. The server's own refusals (nothing heard, transcription down) arrive as text. #} {% endif %}

{# Composer: the post appends the turn's bubbles; the script takes it from there. Enter sends, Shift+Enter inserts a newline. #}
{{ conversation_field(conversation_id) }} {# The chips are a row of their own above the box: beside it, in the drawer, they took the width the message needs. #}
{% if picker %}{% endif %}
{# Not ``required``: a message may be images alone (the route sends the house line); Send stays disabled until there is text or an image. #} {% if voice %} {# The microphone: records while pressed on, then sends what it heard as the message (static/js/voice.js). data-state is what the assistant is doing; voice_states draws it. #} {% endif %}